西瓜视频iOS启动优化实践

6,921 阅读18分钟

背景

启动是用户使用一款产品的第一印象,长时间的启动等待将会消磨用户的耐心。根据过往实验经验,若应用的启动时间减少,那么则能有效的降低0vv(启动后0播放量),因此启动耗时是西瓜客户端品质的核心指标之一。

启动定义

根据2019年的WWDC视频,苹果将启动分为了6个阶段,如下图。

image

启动每个阶段所做的事情如下:

  • System Interface:Dyld加载共享库和框架、初始化系统底层组件。

  • Runtime Init:初始化语言的runtime、调用所有类的静态初始化方法。

  • UIKit Init:初始化UIApplication和UIApplicationDelegate、开始与系统进行事件处理。

  • Application Init:调用application:willFinishLaunchingWithOptions:和application:didFinishLaunchingWithOptions,之后调用applicationDidBecomeActive:。

  • Initial Frame Render:创建、布局和绘制view,提交并渲染首屏。

  • Extended:App进入前台,可以交互和响应事件。

指标定义

在优化开始前,最重要的事情是建立指标。通常情况下,应用在启动首屏之后还需要请求和渲染首页的数据,只关心启动首屏之前的数据是不够的。因此对于西瓜来说,需要扩展Extended阶段的概念,新增自定义事件的完成时间点。

在西瓜启动优化早期,启动耗时指标被定义为第一个+load到列表渲染完成。

image

对于该指标的开始时间点——第一个+load,为了保证埋点统计的准确性,需要通过一些特殊方法来获取它。根据苹果文档,调用+load方法前会进行动态库链接,而CocoasPods管理的动态库的+load方法执行顺序会按动态库名字字母排序。因此只需要新增一个以AAA开头命名的动态库,并在该动态库的+load方法中记录一下时间戳即可。

对于结束时间点——列表渲染完成,同样为了保证埋点统计的准确性,可以利用系统渲染原理来实现。通常,使用CATransaction是一个很好的选择,但它有一个潜在的隐患在于若刷新的cell中包含永远重复的动画,那么CompletionBlock将会等待动画结束后才回调。

[CATransaction begin];
[CATransaction setCompletionBlock:^{
    //列表渲染完成
}];
[self.tableView reloadData];
[CATransaction commit];

由于该隐患的存在,启动指标曾经坏了两次。因此启动指标使用了另外一个方案,接口如下。通过hook列表的layoutSubviews方法,在该方法执行完时通过dispatch_async抛出回调。经过多次线下测试、线上数据与之前CATransaction方案的数据对比,可以证明使用该方案统计的列表渲染耗时是准确的。

[self.tableView xig_reloadDataWithCompletion:^{
    //列表渲染完成
}];

在上述指标运行一段时间后,在体感测试中发现该指标并不是一个可感知指标,无法反应用户从点击app开始到首页展示的整体时长,决定修改启动耗时的口径。将开始时间调整为点击app,将结束时间点调整为列表展示。

image

对于开始时间点,可以使用进程创建的时间戳,demo如下:

+ (NSTimeInterval)time {
    struct kinfo_proc kProcInfo;
    NSTimeInterval processStartTime = 0;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        processStartTime = kProcInfo.kp_proc.p_un.__p_starttime.tv_sec + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000000.0;
        processStartTime *= 1000;
    }
    return processStartTime;
}

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc *)procInfo {
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd) / sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

对于结束时间点,西瓜在首页列表请求时,有一个加载动画的View覆盖在列表上,在列表渲染完成并使加载动画隐藏时,也会有一段额外的耗时,因此西瓜启动首次刷新耗时的结束时间点不是列表渲染完成而是列表展示。统计加载动画隐藏时间点可以利用系统会在runloop的beforeWaiting阶段刷新View时机,再dispatch_async到下一个runloop回调即可,demo如下:

- (void)reload {
    [self.tableView xig_reloadDataWithCompletion:^{
        //触发加载动画关闭
        [self.tableView xig_endUpdateData:NO];
        //监听加载动画关闭
        [self observeNextRenderWithBlock:^{
            //加载动画关闭
        }];
    }];
}

- (void)observeNextRenderWithBlock:(dispatch_block_t)block {
    CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
    CFRunLoopActivity activities = kCFRunLoopBeforeWaiting | kCFRunLoopExit;
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
        CFRelease(observer);
        if (block) {
            dispatch_async(dispatch_get_main_queue(), block);
        }
    });
    CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}

除了统计整体耗时之外,还应关注启动首屏后分阶段的耗时,比如列表创建耗时、请求耗时和列表渲染耗时等。最终,在App进入前台后,将会统计发请求、列表渲染完成、列表展示、首张图片展示和播放器第一帧展示等时间点。对于西瓜而言,启动优化的终极目标是减少从点击应用到播放器第一帧展示的耗时。

image

启动架构

由于西瓜iOS旧启动器不能很好的满足各种需求,比如低端机、新用户等场景的不同启动任务配置,依赖关系不明确,逻辑混乱,调整启动任务会crash等问题。为了能够更好的支撑启动的稳定性、开发效率和优化效率,在启动优化的同时,对启动器进行了重构。

分阶段启动

考虑到理解成本,启动被重新划分为4个阶段:didFinishLaunch、launchCompletion(启动首屏结束)、homeDidRendered(首页渲染完成)和AfterPlayerFirstFrame(播放器首帧)。

image

将基础组件组件初始化放在didFinishLaunch启动,如APM SDK、网络库、埋点库等。将业务组件初始化放在launchCompletion启动。将列表渲染后才会使用的业务和非启动核心业务或放在homeDidRendered和AfterPlayerFirstFrame启动。

启动器设计

启动器支持3个队列,分别是主队列、闲时队列和并发队列。在启动的每个阶段,构建DAG图来实现任务之间的依赖关系,由拓扑排序来决定启动任务执行顺序。考虑到启动任务数量很大,所以数据结构使用邻接表。在拓扑排序的过程中可以检查环,检查出环后进行assert并输出形成环的任务。通过拓扑排序,可以获得一个任务队列,此时每个队列中任务执行结束后询问任务队列是否满足可执行的需求,若满足则取出执行,示意图如下。

image

启动器类图如下,StartupManager用于注册任务、添加匿名任务和在业务模块中被调用来执行任务。启动任务需要实现StartupTask协议,并在AppDelegate中的application:willFinishLaunchingWithOptions:中被注册,可以根据不同类型的用户注册不同的任务。在StartupTask协议中,dependencies类方法用于返回一个数组,其中每一个元素为依赖任务的类,taskQueue决定启动任务派发的队列。

image

启动任务注册

西瓜启动器的注册利用了字节内部的Gaia组件,在启动时会调用所有相关函数将启动任务注册在启动器中。

Gaia利用编译器会将__attribute__((section())) 的数据写到指定数据段中的特性,在编译期间将需要调用的函数指针写入到MachO,在启动运行时通过注册_dyld_register_func_for_add_image回调来读取函数指针进行调用。

比如西瓜启动器用的Gaia注册启动任务的函数为XIGRegisterStartUpTaskFunction(),那么它展开前后对比如下:

//展开前
XIGRegisterStartUpTaskFunction() {
    //注册XIGDemoTask启动任务
    [XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}

//展开后
typedef struct _GAIAData {
    const GAIAType type;
    const bool repeatable;
    const char *key;
    const void *value;
} GAIAData;

typedef struct _GAIAFunctionInfo {
    const void *function;
    const char *fileName;
    const int line;
} GAIAFunctionInfo;

__attribute__((used)) static void __GAIA_ID__0(void);
static const GAIAFunctionInfo __GAIA_F_I_ID__0 = (GAIAFunctionInfo){(void *)__GAIA_ID__0, __FILE_NAME__, __LINE__};
__attribute__((used, no_sanitize_address, section("__DATA,__GAIA__SECTION"))) static const GAIAData __GAIA_ID__1 = (GAIAData){GAIATypeFunctionInfo, false, "XIGRegisterStartUpTask", &__GAIA_F_I_ID__0};
__attribute__((used, no_sanitize_address)) static void __GAIA_ID__0() {
    [XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}

在启动后,调用Gaia执行如下代码注册所有启动任务。

[GAIAEngine startTasksForKey:@XIGRegisterStartUpTaskGaiaKey];

最终一个启动任务使用效果如下:

@implementation XIGDemoTask

XIGRegisterStartUpTaskFunction() {
    [XIGStartUp registerTaskClass:XIGDemoTask.class inStage:XIGStartUpLaunchCompletion];
}

- (void)execute {
    //执行任务
}

@end

问题说明

启动时面临的主要问题如下:

问题影响阶段优先级
执行大量+loadRuntime Initp1
执行大量静态初始化Runtime Initp2
主线程阻塞、耗时操作Runtime Init、Application Initp0
首屏渲染耗时长Initial Frame Renderp0
发首刷请求晚首刷p0
列表创建晚首刷p1
列表渲染耗时首刷渲染p0
大量网络请求抢占首刷请求资源首刷p0
大量后台线程抢占CPU资源首刷p2

优化思路

在启动方向上,主要分两大块进行优化,它们分别是冷启动阶段和启动后阶段。冷启动阶段的耗时关乎到启动首屏的体验,而启动后阶段的耗时关乎到列表首次展示和播放器首帧的体验。由于西瓜启动优化的终极目标是减少播放首帧的耗时,因此在优化过程中,不能顾此失彼,单纯的将冷启动阶段的耗时任务往后放或者将启动后阶段的阻塞任务往前提,都无法有效的改善启动体验。应当以整体的眼光来看待启动耗时,做真实有效的耗时优化。

耗时任务治理

+load与静态初始化

+load与静态初始化本身可能并不耗时,但是它们会造成虚拟内存缺页中断,造成额外的耗时。在一次Trace中观察Runtime Init阶段,该阶段的整体耗时为736ms,其中虚拟内存缺页中断的耗时就高达579ms,因此治理该阶段问题收益较大。

image

治理的思路是替换实现,将西瓜业务范围内的+load和静态初始化函数替换为西瓜的启动任务。出于成本考虑,目前没有治理三方库、两方库以及C++库中的+load和静态初始化。目前在西瓜的业务库中几乎不存在+load方法以及__attribute__((constructor)) void修饰的方法,为了防止后续新增,在MR的静态检查中设立了限制其新增的规则。

//替换前
+ (void)load {}
__attribute__((constructor)) void demoFunc() {}

//替换后
XIGRegisterStartUpTaskFunction() {
    [XIGStartUp registerTaskInStage:XIGStartUpDidFinishLaunch usingBlock:^{}];
}

主线程阻塞

主线程阻塞基本上有三类,分别是虚拟内存缺页中断、进程间通信和等待锁。其中第一个问题可以忽视,主要看进程间通信和锁这两个问题。

对于进程间通信,常见的例子是使用系统库获取一些必要数据,比如说去获取keychain中的数据,获取idfa、udid等等。对于此类问题,优化思路有两种,一种是在使用前在后台线程提前获取并缓存在内存中,当使用时直接使用缓存数据,此方案必须需要考虑的问题是系统库是否线程安全。另一种是在第一次获取后就写入持久化缓存,比如YYCache和MMKV等,之后使用时只需要读取本地缓存即可。

对于锁而言,需要具体问题具体分析。比如需要警惕主线程和子线程同时调用一个线程安全的组件,这种情况下很大概率出现主线程等待子线程上的锁导致主线程阻塞。还比如业务侧自己加的锁,这种情况需要改造逻辑,尽量通过逻辑来避免加锁,或者将调用派发到指定的串行队列来执行。最后,还会出现一些无需优化的锁来干扰,比如Trace中经常会出现_objc_msgSend_uncached内的锁,这种情况就属于调用OC方法时没有方法缓存导致需要找方法而阻塞了主线程,可以忽略。

耗时操作

耗时操作有非必须启动和必须启动两种。本文根据启动任务是否影响启动后的用户消费来判断一个启动任务是否必须。比如对于WKWebview预加载任务来说,这就是一个非必须启动任务。

针对非必须启动任务,可以调整执行时机、打散执行、后台线程执行或者进行懒加载。针对启动必要任务,可以通过降低其本身耗时、调整执行时机和使其支持子线程执行等手段来解决。

image

不同的产品会有不同的非必要启动任务,比如对于西瓜来说,小程序就是一个渗透率极低耗时较大的启动任务。西瓜进入小程序日均pv极少,但每个用户却都需要将其启动,完全是没有必要的。因此西瓜中的小程序服务被做成了懒加载的形式。另外,像直播和创作组件的渗透率也相对较低,未来会考虑将这些组件的启动一样做成懒加载形式。

对于必要的启动任务,除了降低其本身耗时和放在后台线程执行以外,也可以采用一些取巧的手段。比如西瓜的播放器创建耗时较久,为了能够让启动后视频能够尽快播放,品质团队做了播放器预热功能,提前创建好播放器。这个优化的主要设计难点在于如何选取预热的时机。通过埋点数据分析可以确认一个时机为弹窗弹出时,此时用户会等待一段时间看弹窗,非常适合做资源预加载。但不是每次启动都有弹窗,因此还需要另一个时机。看如下Trace,西瓜在启动首屏后会有一段时间在等待网络请求,runloop出于空闲状态,那么这个时机就呼之欲出,在启动首屏后。

image

在确认时机后,只需要利用西瓜的启动器在首屏后的闲时队列调用播放器预热即可。

@implementation XIGPlayerPreheatTask

XIGRegisterStartUpTaskFunction() {
    [XIGStartUp registerTaskClass:XIGPlayerPreheatTask.class inStage:XIGStartUpLaunchCompletion];
}

- (void)execute {
    //调用播放器预热
}

- (XIGStartUpTaskQueue)taskQueue {
    return XIGStartUpTaskMainIdleQueue;
}

@end

最后,除了关注主线程的耗时操作之外,还应当适度关注子线程的耗时操作。在低端机设备上,CPU仅有两个核心,因此因此在启动时子线程的操作会与主线程竞争CPU资源。将部分启动组件内部的队列调用移除后,使用ByTest对低端机进行测试,启动首刷耗时有了较显著的下降。

渲染优化

西瓜的首页视图层级如下,一般情况下第一个cell会是关注频道,第二个cell会是推荐频道,在启动后会定位在推荐频道。由于UICollectionView的特性决定了在刷新数据时无法跳过关注频道直接刷新推荐频道,因此长期以来西瓜启动后均会请求两个数据流接口并渲染两个列表,存在额外的耗时开销。

image

解决这个问题的优化方案是在启动首次刷新时,在collectionView:collectionView cellForItemAtIndexPath:中询问上层是否可以调用该cell的刷新逻辑,而上层根据该cell是否是启动后需要定位的频道来决定。

网络优化

除了纯网络层面的优化如使用QUIC协议、请求预建链和精简字段等,客户端层面还可以做一些策略来使得等待网络的耗时减少。西瓜在此方面有三个重要的优化,一个是网络调度,第二个是首刷请求提前发送,最后是图片和视频预加载。

网络调度

经过线下分析发现,启动时西瓜的首刷请求需要进入到网络库中进行排队,并且许多请求完成后处理数据也十分耗时。针对这些问题,改造每一个启动时网络请求显然是不现实的。因此,实现一个网络调度器让西瓜在启动时能够决定网络请求发送时机才是一个合适的方案,这样可以让其他请求在首刷请求之后发起。目前,西瓜大多数启动时发送的网络请求都会被延迟到首刷展示之后。

image

当然,启动并不只有点击App一种,西瓜还存在其他启动模式,比如点击Widget启动西瓜进“我的”页面。此时用户的消费场景并不是“首页”而是“我的”页面,因此可以在启动任务中判断进入的页面来决定是否取消网络调度。

首刷请求提前

在不优化首刷请求的耗时情况下,想要使等待首刷请求的时间变少,那就可以提前请求首刷。在优化前,首刷请求会在列表初始化后才开始发送,并且会有多个队列与主队列交互,增加了许多线程调度成本。优化后,首刷请求会紧接着网络库的初始化之后发送,并在一个串行队列做好数据解析,网络条件好时,列表创建后就可以直接拿到首刷数据进行渲染。即使网络条件差一点,也会减少等待的时间。

image

图片和视频预加载

西瓜的首刷数据解析后需要写入数据库,之后再回调到上层渲染和展示,为了充分利用等待数据解析和视图渲染的时间,西瓜在数据解析后就开始提前预加载图片和第一个视频。

image

防劣化与监控

随着西瓜启动性能的持续优化,西瓜的启动耗时得到了有效的治理。为了保持优化效果以及感知线上劣化情况,引入了防劣化和监控环节。

线下防劣化

整体流程

线下防劣化采用了ByTest(质量平台)指标防劣化方案,其整体流程如下。每天会触发两次性能防劣化服务,每次触发服务后会构建一个性能防劣化包交付ByTest进行自动化测试。App在启动后上报启动相关埋点数据到Slardar(端监控平台),ByTest在获取埋点数据后通过算法排除掉异常数据,判断劣化情况并发送通知。

流程图.jpg

相关负责人收到通知后可以通过ByTest提供的数据进一步分析劣化情况,目前ByTest提供了一套自动化分析报告,其中会提供基准包与测试包的Trace、Settings(客户端配置服务)差异以及首页的截图。从中可以发现比较明显的劣化问题。有时问题比较复杂,自动化分析并不一定准确,可以使用ByTrace(性能分析平台)调用栈或火焰图来进行辅助分。如果都不能起到帮助,则需要本地使用Instruments进行分析。

image

image

稳定性

为了保障自动化测试的稳定性,ByTest同学做了诸多控制变量的工作,提高测试结果的置信度,减小噪音干扰。比如在设备层面控制了CPU和GPU的频率、关闭了启动闭包、退出Apple ID和iCloud ID。在网络层面通过代理服务器实现网络请求的录制与回放来减少了网络请求中涉及的环节,降低了网络请求中的噪音。

除此之外,在埋点数据处理时,ByTest也会过滤掉异常点,并提供一次劣化告警的置信度来进一步保障数据的稳定性。

image

效果

在年初接入ByTest防劣化服务前,西瓜第一个+load到首屏的指标存在大幅度的变化。在接入后,由于持续的防劣化消费,使得指标保持在一个相对稳定的水平。

image

线上指标监控

与线下防劣化相对应的,还需要建立线上指标监控。若一个启动相关功能由Settings控制,仅在发布后打开开关,那么很大可能会在线下防劣化的过程中逃逸,因此线上指标监控能够及时发现问题。目前西瓜已经在Tea(行为分析系统)上的看板建立了多个维度的监控,包含启动的各个链路。

二进制重排自动化

二进制重排是一个比较有收益的启动优化,但在过去很长一段时间中,orderfile文件是手动跑出来的,比较消耗人力。在研发平台和QA同学的协助下,目前更新orderfile的流程已经完全自动化,流程如下。根据研发平台日历,每天会判断是否是一灰最后一天,若判断成功会触发二进制重排云构建打包,传包到ByTest服务进行启动测试来获取Trace文件,在服务端生成orderfile后自动发起MR。

流程图 (1).jpg

总结

西瓜是一个迭代迅速的产品,每周在都会合入许多启动相关的代码,因此除了优化之外,还需要重视防劣化,避免一边优化一边劣化而相互抵消效果。另外,在优化与防劣化之外,架构的建设也十分重要,合理的架构可以提高App稳定性和研发效率。目前西瓜的启动架构还存在较多历史包袱,未来品质团队将会进一步重构,并继续探索启动的其他优化途径,如分场景分人群启动、首屏渲染性能等等。